import { promises, watch } from 'node:fs';
import path from 'node:path';
import { logger } from '../../core/logger.js';
import { hasProperties } from './utils.js';

class Watcher {
  // Initialize with paths to watch and options.
  constructor(paths, options) {
    this.paths = paths;
    this.options = options;
  }
  // Map to keep track of FSWatcher instances for each watched file.
  watchers = /* @__PURE__ */ new Map();
  // Map to keep track of last modification date for each watched file.
  watchedFiles = /* @__PURE__ */ new Map();
  // A flag to avoid triggering callbacks on the initial setup.
  isFirstTime = true;
  debouncer;
  // Start the watcher. Files are read, and callbacks are set up.
  async start(callback) {
    this.debouncer = new Debouncer(callback);
    await Promise.all(
      this.paths.map((filePath) => {
        return this.watchPath(filePath, this.options, (changeType, filePath2) => {
          this.debouncer.addChange({ changeType, filePath: filePath2 });
        });
      })
    );
    this.isFirstTime = false;
  }
  // Stop the watcher. Close all FSWatcher instances and clear the map.
  stop() {
    this.watchers.forEach((watcher) => {
      watcher.close();
    });
    this.watchers.clear();
  }
  // Retry function that repeats a promise-returning function up to a number of times.
  async retry(fn, retries = 2) {
    try {
      return await fn();
    } catch (err) {
      if (retries === 0) {
        throw err;
      }
      await new Promise((resolve) => setTimeout(resolve, 1e3));
      return await this.retry(fn, retries - 1);
    }
  }
  // Set up watching a path. Recursively applies to directories, unless excluded by options.
  async watchPath(targetPath, options, callback) {
    const stats = await promises.lstat(targetPath);
    if (options.exclude?.includes(targetPath)) {
      return;
    }
    if (stats.isFile()) {
      this.watchFile(targetPath, callback);
      if (!this.isFirstTime) {
        callback("added", targetPath);
      }
    } else if (stats.isDirectory() && (!options.exclude || !options.exclude.includes(path.basename(targetPath)) && !options.exclude.includes(targetPath))) {
      const files = await this.retry(() => promises.readdir(targetPath, { withFileTypes: true }));
      for (const file of files) {
        const filePath = path.join(targetPath, file.name);
        await this.watchPath(filePath, options, callback);
      }
      const watcher = watch(targetPath, async (event, filename) => {
        if (filename) {
          const newFilePath = path.join(targetPath, filename);
          if (event === "rename") {
            try {
              await promises.access(newFilePath, promises.constants.F_OK);
              if (!this.watchers.has(newFilePath)) {
                await this.watchPath(newFilePath, options, callback);
                if (!this.isFirstTime) {
                  callback("added", newFilePath);
                }
              } else {
                const watcher2 = this.watchers.get(newFilePath);
                if (watcher2) {
                  watcher2.close();
                }
                this.watchers.delete(newFilePath);
                await this.watchPath(newFilePath, options, callback);
              }
            } catch (e) {
              if (hasProperties(e, ["code"]) && e.code === "ENOENT") {
                const watcher2 = this.watchers.get(newFilePath);
                if (watcher2) {
                  callback("removed", newFilePath);
                  watcher2.close();
                  this.watchers.delete(newFilePath);
                }
              } else {
                logger.error(`Unable to access file: ${newFilePath}`);
              }
            }
          }
        }
      });
      this.watchers.set(targetPath, watcher);
    } else if (stats.isSymbolicLink()) {
      const realPath = await promises.realpath(targetPath);
      await this.watchPath(realPath, options, callback);
    }
  }
  // Watch a single file. Set up the FSWatcher and callback for changes.
  watchFile(filePath, callback) {
    const watcher = watch(filePath, async (event) => {
      if (event === "rename") {
        try {
          await this.retry(() => promises.access(filePath, promises.constants.F_OK));
          if (!this.isFirstTime) {
            callback("added", filePath);
          }
        } catch (e) {
          if (hasProperties(e, ["code"]) && e.code === "ENOENT") {
            const watcher2 = this.watchers.get(filePath);
            if (watcher2) {
              callback("removed", filePath);
              watcher2.close();
              this.watchers.delete(filePath);
            }
          } else {
            logger.error(`Unable to access file: ${filePath}`);
          }
        }
      } else if (event === "change") {
        const stat = await promises.lstat(filePath).catch((e) => {
          if (hasProperties(e, ["code"]) && e.code === "ENOENT") {
            const watcher2 = this.watchers.get(filePath);
            if (watcher2) {
              callback("removed", filePath);
              watcher2.close();
              this.watchers.delete(filePath);
            }
            return null;
          } else {
            throw e;
          }
        });
        if (!stat)
          return;
        if (this.watchedFiles.get(filePath)?.getTime() !== stat.mtime.getTime()) {
          this.watchedFiles.set(filePath, stat.mtime);
          if (!this.isFirstTime) {
            callback("changed", filePath);
          }
        }
      }
    });
    this.watchers.set(filePath, watcher);
  }
}
class Debouncer {
  callback;
  wait;
  timerId;
  changes;
  constructor(callback, wait = 100) {
    this.callback = callback;
    this.wait = wait;
    this.timerId = null;
    this.changes = /* @__PURE__ */ new Map();
  }
  addChange(change) {
    this.changes.set(change.filePath, change);
    this.startTimer();
  }
  startTimer() {
    if (this.timerId !== null) {
      clearTimeout(this.timerId);
    }
    this.timerId = setTimeout(() => this.invokeCallback(), this.wait);
  }
  invokeCallback() {
    this.callback(Array.from(this.changes.values()));
    this.changes.clear();
  }
}

export { Watcher as default };
